● Zion Boggan
repos/Docket/app/votes/[id]/page.tsx
zionboggan.com ↗
368 lines · tsx
History for this file →
1
import type { Metadata } from "next";
2
import Link from "next/link";
3
import { notFound } from "next/navigation";
4
import { Card, PageHeader, SectionHeader } from "@/components/ui";
5
import { DataTable, type Column, type Row } from "@/components/data-table";
6
import { SourceLink } from "@/components/source-link";
7
import { StatusPill, classifyStatus } from "@/components/status-pill";
8
import { VoteTally } from "@/components/vote-tally";
9
import { PositionTag, positionColor } from "@/components/position-tag";
10
import { PartyTag } from "@/components/party-tag";
11
import { MemberAvatar } from "@/components/member-avatar";
12
import { getVoteById, type VotePosition } from "@/lib/feed";
13
import { resolveVoteSubject } from "@/lib/vote-subject";
14
import { formatDate, formatBillNumber } from "@/lib/format";
15
import { CiteThis } from "@/components/cite-this";
16
import { ArchiveLink } from "@/components/record-archive";
17
 
18
export const revalidate = 300;
19
 
20
export function generateMetadata({
21
  params,
22
}: {
23
  params: { id: string };
24
}): Metadata {
25
  const numeric = parseInt(params.id.replace(/^vote:/, ""), 10);
26
  const vote = Number.isFinite(numeric) ? getVoteById(numeric) : null;
27
  if (!vote) return { title: "Vote not found" };
28
  const subject = resolveVoteSubject(vote);
29
  return {
30
    title: `${vote.chamber} vote · ${formatDate(vote.date)}`,
31
    description: subject.primary,
32
  };
33
}
34
 
35
 
36
const POSITION_ORDER: Record<string, number> = {
37
  yea: 0,
38
  nay: 1,
39
  present: 2,
40
  "not voting": 3,
41
};
42
function rosterSort(a: VotePosition, b: VotePosition): number {
43
  const pa = POSITION_ORDER[(a.position || "").toLowerCase()] ?? 9;
44
  const pb = POSITION_ORDER[(b.position || "").toLowerCase()] ?? 9;
45
  if (pa !== pb) return pa - pb;
46
  return a.member.localeCompare(b.member);
47
}
48
 
49
export default function VoteDetailPage({
50
  params,
51
}: {
52
  params: { id: string };
53
}) {
54
  const voteUrlId = params.id;
55
  const numeric = parseInt(params.id.replace(/^vote:/, ""), 10);
56
  const vote = Number.isFinite(numeric) ? getVoteById(numeric) : null;
57
  if (!vote) notFound();
58
 
59
  const variant = classifyStatus(vote.result);
60
  const passed = vote.yea > vote.nay;
61
  const roster = [...vote.positions].sort(rosterSort);
62
 
63
  const counts = vote.positions.reduce<Record<string, number>>((m, p) => {
64
    const key = (p.position || "Not Voting").toLowerCase();
65
    m[key] = (m[key] ?? 0) + 1;
66
    return m;
67
  }, {});
68
 
69
  const subject = resolveVoteSubject(vote);
70
 
71
  return (
72
    <div
73
      className="mx-auto w-full max-w-[860px] px-6 sm:px-8"
74
      style={{
75
        paddingTop: "clamp(32px, 6vw, 56px)",
76
        paddingBottom: "clamp(48px, 8vw, 80px)",
77
      }}
78
    >
79
      <Link
80
        href="/votes"
81
        className="focus-ring tap no-underline hover:underline"
82
        style={{
83
          display: "inline-flex",
84
          alignItems: "center",
85
          gap: "8px",
86
          fontFamily: "var(--font-mono), ui-monospace, monospace",
87
          fontSize: "13px",
88
          letterSpacing: "0.08em",
89
          color: "var(--dk-signal)",
90
          marginBottom: "32px",
91
        }}
92
      >
93
        ← Votes
94
      </Link>
95
 
96
      {
97
}
98
      <div className="mt-4 mb-6 rounded-[3px] border border-line bg-surface p-5">
99
        <p className="label mb-1.5 text-ash">
100
          {subject.kind === "bill"
101
            ? "Bill voted on"
102
            : subject.kind === "nomination"
103
            ? "Presidential nomination"
104
            : subject.kind === "treaty"
105
            ? "Treaty ratification"
106
            : subject.kind === "ref"
107
            ? "Legislative document"
108
            : "Question voted on"}
109
        </p>
110
 
111
        {}
112
        {subject.kind === "bill" && vote.billId != null ? (
113
          <p className="font-mono text-[14px] leading-snug text-paper">
114
            <Link
115
              href={`/bill/${vote.billId}`}
116
              className="focus-ring underline underline-offset-4 hover:text-paper"
117
            >
118
              {formatBillNumber(vote.billNumber)}
119
            </Link>
120
            {vote.billTitle ? (
121
              <span className="text-ash"> - {vote.billTitle}</span>
122
            ) : null}
123
          </p>
124
        ) : (
125
          <p className="font-mono text-[14px] leading-snug text-paper">
126
            {subject.primary}
127
          </p>
128
        )}
129
 
130
        {}
131
        {subject.secondary && subject.secondary !== vote.result ? (
132
          <p className="mt-2 text-[13px] text-ash">{subject.secondary}</p>
133
        ) : vote.question && subject.kind === "bill" ? (
134
          <p className="mt-2 text-[13px] text-ash">{vote.question}</p>
135
        ) : null}
136
      </div>
137
 
138
      <PageHeader
139
        eyebrow={`${vote.chamber} · ${formatDate(vote.date)}${
140
          vote.rollNumber ? ` · Roll ${vote.rollNumber}` : ""
141
        }`}
142
        title={vote.question || vote.result || "Roll-call vote"}
143
        lead={vote.result}
144
      />
145
 
146
      {}
147
      <div className="grid gap-4 sm:grid-cols-3">
148
        <Card className="p-5">
149
          <p className="label mb-2">Result</p>
150
          <StatusPill variant={variant} dot>
151
            {passed ? "Passed" : "Failed"}
152
          </StatusPill>
153
          {
154
}
155
          {vote.result &&
156
          vote.result.trim().toLowerCase() !== (passed ? "passed" : "failed") ? (
157
            <p className="mt-2 font-mono text-[12px] text-ash-2">{vote.result}</p>
158
          ) : null}
159
        </Card>
160
 
161
        <Card className="p-5">
162
          <p className="label mb-2">Chamber</p>
163
          <p className="font-mono text-[13px] text-paper">{vote.chamber}</p>
164
          <p className="label mt-2 text-ash-2">{formatDate(vote.date)}</p>
165
        </Card>
166
 
167
        <Card className="p-5">
168
          <p className="label mb-2">Official record</p>
169
          <SourceLink url={vote.sourceUrl} label="Roll call" />
170
          {vote.archiveUrl ? (
171
            <>
172
              <div className="mt-2">
173
                <ArchiveLink url={vote.archiveUrl} />
174
              </div>
175
              <p className="label mt-2 text-ash-2">
176
                Docket preserves a copy of the roll-call record.
177
              </p>
178
            </>
179
          ) : (
180
            <p className="label mt-2 text-ash-2">
181
              {vote.question ? "Question of the vote shown above." : "No question text on file."}
182
            </p>
183
          )}
184
        </Card>
185
      </div>
186
 
187
      {vote.recorded ? (
188
        <>
189
          {}
190
          <div className="mt-10 grid gap-8 lg:grid-cols-[1fr_1.4fr]">
191
            <div>
192
              <SectionHeader eyebrow="Tally" title="Vote total" />
193
              <Card className="p-5">
194
                <VoteTally
195
                  yea={vote.yea}
196
                  nay={vote.nay}
197
                  present={vote.present}
198
                  result={passed ? "passed" : "failed"}
199
                />
200
                {vote.notVoting ? (
201
                  <p className="label mt-4 text-ash-2">
202
                    {vote.notVoting} member{vote.notVoting === 1 ? "" : "s"} not voting.
203
                  </p>
204
                ) : null}
205
                <div className="mt-4">
206
                  <SourceLink url={vote.sourceUrl} />
207
                </div>
208
              </Card>
209
            </div>
210
 
211
            {}
212
            <div>
213
              <SectionHeader eyebrow="By position" title="How members voted" />
214
              <Card className="p-5">
215
                <div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
216
                  <SummaryChip label="Yea" value={counts.yea ?? vote.yea} color="var(--dk-yea)" />
217
                  <SummaryChip label="Nay" value={counts.nay ?? vote.nay} color="var(--dk-nay)" />
218
                  <SummaryChip
219
                    label="Present"
220
                    value={counts.present ?? vote.present}
221
                    color="var(--dk-ash)"
222
                  />
223
                  <SummaryChip
224
                    label="Not voting"
225
                    value={counts["not voting"] ?? 0}
226
                    color="var(--dk-ash-2)"
227
                  />
228
                </div>
229
                <p className="label mt-4 text-ash-2">
230
                  Default order: by position, then alphabetical - not grouped by party.
231
                </p>
232
              </Card>
233
            </div>
234
          </div>
235
 
236
          {}
237
          <div className="mt-12">
238
            <SectionHeader
239
              eyebrow={`Roster · ${roster.length} member${roster.length === 1 ? "" : "s"}`}
240
              title="Every member's position"
241
              description="Name, party, state, and district for each member, with how they voted. Sorted by vote then alphabetical."
242
            />
243
            {roster.length === 0 ? (
244
              <Card className="p-5">
245
                <p className="font-mono text-[13px] text-ash">
246
                  No per-member positions are linked to this vote in the record.
247
                </p>
248
              </Card>
249
            ) : (
250
              <VoteRoster roster={roster} chamber={vote.chamber} />
251
            )}
252
            <p className="label mt-4 text-ash-2">
253
              Party shown in desaturated, equal-weight tones. Source: {""}
254
              <span style={{ color: positionColor("Yea") }}>Yea</span>,{" "}
255
              <span style={{ color: positionColor("Nay") }}>Nay</span> - never vivid red or blue.
256
            </p>
257
          </div>
258
        </>
259
      ) : (
260
        <div className="mt-10">
261
          <SectionHeader eyebrow="No individual record" title="Passed without a roll call" />
262
          <Card className="p-6">
263
            <p className="prose-lead text-paper">
264
              No individual record exists - this action passed by voice vote or
265
              unanimous consent. The chamber did not record how each member voted.
266
            </p>
267
            <p className="mt-4 font-mono text-[13px] text-ash">
268
              Recorded result: {vote.result || "Agreed"}.
269
            </p>
270
            <div className="mt-4">
271
              <SourceLink url={vote.sourceUrl} />
272
            </div>
273
          </Card>
274
        </div>
275
      )}
276
 
277
      {}
278
      <div className="mt-12 border-t border-line pt-8">
279
        <p className="label mb-4 text-ash">Cite this record</p>
280
        <CiteThis
281
          kind="vote"
282
          title={`${vote.chamber} Roll-Call Vote - ${subject.primary} (${formatDate(vote.date)})`}
283
          permalink={`https://usdocket.org/votes/${voteUrlId}`}
284
          sourceUrl={vote.sourceUrl}
285
        />
286
      </div>
287
    </div>
288
  );
289
}
290
 
291
function SummaryChip({
292
  label,
293
  value,
294
  color,
295
}: {
296
  label: string;
297
  value: number;
298
  color: string;
299
}) {
300
  return (
301
    <div className="border border-line p-3">
302
      <div className="label flex items-center gap-1.5" style={{ color }}>
303
        <span
304
          aria-hidden="true"
305
          className="inline-block h-1.5 w-1.5 rounded-full"
306
          style={{ backgroundColor: color }}
307
        />
308
        {label}
309
      </div>
310
      <div className="mt-1 font-mono text-xl tabular-nums text-paper">{value}</div>
311
    </div>
312
  );
313
}
314
 
315
 
316
function formatMemberDistrict(chamber: string, district: number | null): string {
317
  if ((chamber ?? "").toLowerCase() === "senate") return "";
318
  if (district === null || district <= 0) return " · At-Large";
319
  return ` · Dist. ${district}`;
320
}
321
 
322
function VoteRoster({ roster, chamber }: { roster: VotePosition[]; chamber: string }) {
323
  const columns: Column[] = [
324
    { key: "member", header: "Member", cardTitle: true },
325
    { key: "party", header: "Party" },
326
    { key: "state", header: "State" },
327
    { key: "position", header: "Position" },
328
  ];
329
  const rows: Row[] = roster.map((p, i) => ({
330
    key: `${p.memberHref || i}-${p.member}-${i}`,
331
    cells: {
332
      member: (
333
        <span className="inline-flex items-center gap-2 text-paper">
334
          <MemberAvatar name={p.member} photoUrl={p.photoUrl} size="sm" />
335
          <span className="min-w-0">
336
            {p.memberHref ? (
337
              <Link
338
                href={p.memberHref}
339
                className="focus-ring rounded-sm underline underline-offset-4 hover:text-paper"
340
              >
341
                {p.member}
342
              </Link>
343
            ) : (
344
              p.member
345
            )}
346
          </span>
347
        </span>
348
      ),
349
      party: p.party ? <PartyTag party={p.partyCode} /> : "-",
350
      state: (
351
        <span className="text-ash">
352
          {p.state}
353
          {formatMemberDistrict(chamber, p.district)}
354
        </span>
355
      ),
356
      position: <PositionTag position={p.position} />,
357
    },
358
  }));
359
  return (
360
    <DataTable
361
      caption="Member roster for this vote: name, party, state, district, position."
362
      cardLabel="Member roster"
363
      columns={columns}
364
      rows={rows}
365
      pageSize={50}
366
    />
367
  );
368
}